Testowanie schematu struktur danych to dobra praktyka zapewnia nas bowiem, że Mongoose będzie w stanie dbać o poprawność informacji wprowadzanych do bazy.
Niemniej jednak w żaden sposób nie daje nam to gwarancji, że sama komunikacja działa bez zarzutów. Na przykład może okazać się, że z jakiegoś powodu metoda .save, mimo użycia poprawnych danych, nie doda dokumentu do bazy, albo metoda updateOne niepoprawnie ją zaktualizuje itd. Mogą pojawić się też inne problemy, np. w sytuacji, gdy mamy dwie kolekcje, które są ze sobą jakoś powiązane (wykorzystują ref). Warto wtedy sprawdzić, czy find wraz z populate, faktycznie zwróci poprawne dane.
W tym submodule zajmiemy się właśnie takimi przypadkami i testami działania metod CRUD.
Przygotowania
Wciąż pracujemy na przykładzie z poprzedniego submodułu.
Na początku zastanówmy się, gdzie będziemy przechowywać nowe testy. Od razu do głowy przychodzą dwa pomysły – skoro w folderze models trzymaliśmy testy struktur danych modeli, to może jest to również dobre miejsce do przechowywania testów ich metod? Drugi pomysł byłby kompletnie inny. Będziemy testować operacje CRUD, więc będzie to zestaw całkiem nowych, odrębnych testów, a zatem może warto trzymać je w katalogu głównym, w podfolderze test?
Znowu nie będziemy narzucać żadnego z pomysłów, niemniej jednak dla ułatwienia znajdowania plików (krótsze ścieżki), w niniejszym submodule postawimy na pierwszą ideę.
Zaczniemy od testowania modelu Department. Wejdź teraz do folderu models/test i utwórz tam nowy plik – department.crud.test.js. Oczywiście taka nazwa to tylko nasz wybór, końcówka crud.test.js ma po prostu w założeniu pomagać w łatwej identyfikacji właściwych plików.
Otwórz go teraz i zacznij od zaimportowania modelu Department oraz metody expect z pakietu Chai. Utwórz również blok describe.
const Department = require('../department.model');
const expect = require('chai').expect;
describe('Department', () => {
});
Skoro będziemy korzystać z modelu, to warto przygotować też blok after, w którym będziemy "kasować" model, gdy już go nie potrzebujemy. Jak zapewne pamiętasz z poprzedniego submodułu, jest to konieczne, aby Mocha w trybie --watch mogła obsługiwać go poprawnie.
const Department = require('../department.model');
const expect = require('chai').expect;
describe('Department', () => {
after(() => {
mongoose.models = {};
});
});
Połączenie z bazą danych
Zanim przejdziemy dalej, musimy się na chwilę zatrzymać. W poprzednim submodule nie potrzebowaliśmy połączenia z bazą danych, bo testowaliśmy tylko samo działanie schematów. Tutaj będzie inaczej – chcemy sprawdzać, czy wykorzystanie metod CRUD faktycznie odnosi pożądany skutek.
Jednak, czy aby na pewno chcemy pracować na prawdziwej bazie – dodawać dane, usuwać je i modyfikować w naszych kolekcjach? Niekoniecznie, jeśli z jakiegoś powodu zależy Ci na integralności tych danych. Dobrym wyjściem może być użycie jakiejś paczki, która pozwoli nam na testowanie metod CRUD, bez faktycznej modyfikacji naszej bazy danych.
Na rynku istnieje kilka pakietów, które funkcjonują w ten sposób, a jeden z nich to mongodb-memory-server. Jego działanie jest bardzo sprytne – pozwala na utworzenie tymczasowej bazy danych i użycie jej przy połączeniu. Co istotne, jest ona kreowana w dokładnie taki sam sposób jak "normalne". Wszystkie operacje, które będziemy wykonywać na tej kopii, będą więc działały dokładnie tak samo, jak na oryginale. Różnica jest tylko taka, że jej zawartość będzie przechowywana w pamięci systemu i zostanie skasowana po zakończeniu działania testów. Tym samym otrzymujemy możliwość sprawdzenia działania odpowiednich metod w "warunkach bojowych", bez nienaruszania oryginalnych danych. Brzmi ciekawie, prawda?
Instalacja paczki
Zacznij od zainstalowania tej paczki:
yarn add mongodb-memory-server
Przygotowanie połączenia
Czas na przygotowanie połączenia.
Zacznij od zaimportowania funkcji do tworzenia testowej bazy danych:
const MongoMemoryServer = require('mongodb-memory-server').MongoMemoryServer;
Przyda się też Mongoose:
const mongoose = require('mongoose');
Potrzebujemy jej, bo w końcu tworzymy połączenie. Owszem, baza będzie testowa, ale samo połączenie prawdziwe.
Następnie dodaj do bloku describe, hook before.
before(async () => {
try {
const fakeDB = new MongoMemoryServer();
const uri = await fakeDB.getConnectionString();
mongoose.connect(uri, { useNewUrlParser: true, useUnifiedTopology: true });
} catch(err) {
console.log(err);
}
});
Jak widzisz, kod jest dość krótki.
const fakeDB = new MongoMemoryServer();
Na początku tworzymy nową testową bazę danych. Warto jednak wiedzieć, że mamy możliwość założenia nawet kilku takich "oszukanych" baz na raz.
const uri = await fakeDB.getConnectionString();
Następnie pobieramy adres tej bazy. Domyślnie paczka tworzy ją pod adresem 127.0.0.1 i łączy się za pomocą pierwszego lepszego wolnego portu. Te opcje można jednak w razie potrzeby zmienić.
mongoose.connect(uri, { useNewUrlParser: true, useUnifiedTopology: true });
Na końcu łączymy się z naszą bazą przy użyciu znanej Ci już funkcji mongoose.connect, a jako adres przekazujemy oczywiście fakeDB. Paczka mongodb-memory-server oferuje również funkcję do zakończenia działania naszej bazy, ale nie musimy tego robić. Gdy proces testowania się zakończy, baza i tak usunie się automatycznie.
Dlaczego całość jest schowana w hooku before? Dlatego, że chcemy, aby testy uruchomiły się dopiero wtedy, kiedy mamy pewność, że połączenie jest gotowe.
Uwaga!
Tryb --watch w pakiecie Mocha niezbyt dobrze radzi sobie z obsługą testowej bazy danych. Zresztą, jego wady ujawniły się już wcześniej, gdy mieliśmy problem z duplikowaniem modelu. Bierze się to stąd, że --watch uruchamia testy od nowa, ale tak naprawdę nie kończy całego procesu. Możemy to jednak obejść, sami tworząc task do obserwacji plików z testami.
Idea jest dość prosta. Wykorzystamy tutaj znaną Ci już paczkę onChange. Stworzymy dwa taski – jeden uruchomi Mochę tylko raz, a drugi, za pomocą onChange, będzie obserwował pliki z testami i kiedy wykryje jakąś zmianę, uruchomi task pierwszy.
Będzie to wyglądało następująco:
"test": "mocha \"./{,!(node_modules)/**/}*.test.js\"",
"test:watch": "onchange \"./**/*.js\" -i -k -- yarn test"
test służy do jednorazowego uruchomienia testów, a test:watch uruchamia je raz na początku (dba o to flaga i/--initial) oraz za każdym razem, kiedy wykryje zmianę w jakimkolwiek pliku z testami. Flaga -k (--kill) zapewnia nas, że po zakończeniu testów każdorazowo ten proces jest "zabijany".
Oczywiście, żeby test:watch mógł działać, musisz pobrać do swojego projektu paczkę onChange.
yarn add onchange@6.1.0
Od tej chwili Mocha nie powinna sprawiać nam przy pracy z Mongoose żadnych problemów. Do tego, możesz usunąć ze swoich testów bloki after, które zajmowały się zerowaniem modeli. Nie będziemy już ich potrzebować.
Read
Zaczniemy od przetestowania metod do pobierania danych, sprawdzimy więc find oraz findOne.
W tym celu stworzymy wewnątrz kolejny blok describe, co pomoże rozdzielić poszczególne grupy testów od siebie. Chcemy, aby testy do wybierania danych były w jednym miejscu, a do usuwania w innym.
Od razu przygotujemy też blok it.
describe('Reading data', () => {
it('should return all the data with "find" method', () => {
});
});
Zaczniemy od najprostszego testu. Sprawdzimy, czy find bez żadnych parametrów, zwróci nam po prostu wszystkie dane.
By to zweryfikować, potrzebujemy jakichś testowych danych. Bez nich nie będziemy wiedzieć, czy otrzymaliśmy pustą tablicę, bo kolekcja naprawdę była pusta, czy może po prostu natrafiliśmy na jakiś błąd.
Co istotne, nie tylko pierwszy test z tego bloku musi mieć dostęp do jakichś danych, podobnie będzie w przypadku np. findOne. Tym samym, zamiast organizować je tylko wewnątrz pierwszego it, kod, który się tym zajmie, będziemy przechowywać w bloku before całej grupy testów Reading data.
describe('Reading data', () => {
before(async () => {
const testDepOne = new Department({ name: 'Department #1' });
await testDepOne.save();
const testDepTwo = new Department({ name: 'Department #2' });
await testDepTwo.save();
});
it('should return all the data with "find" method', () => {
});
});
Dzięki temu mamy gwarancję, że zanim którykolwiek test z tej grupy zostanie uruchomiony, dane będą już dostępne. Oczywiście celowo wprowadziliśmy poprawne informacje – zakładamy, że nie spowodują błędu, bo sprawdzaniem takich przypadków zajmowaliśmy się już wcześniej.
Przejdźmy teraz do samego testu. Jak najłatwiej możemy sprawdzić, czy wszystko gra? Wystarczy, że spróbujemy pobrać dane i ustalimy, czy ich liczba to dwa. Nie musimy weryfikować samych danych ani sprawdzać, czy otrzymaliśmy dokument z dobrymi atrybutami – to również testowaliśmy w poprzednim submodule.
it('should return all the data with "find" method', async () => {
const departments = await Department.find();
const expectedLength = 2;
expect(departments.length).to.be.equal(expectedLength);
});
Przyznaj, że kod jest dość logiczny. Szukamy wszystkich danych, a potem sprawdzamy, czy ich ilość jest zgodna z założeniami. Wiemy, że nasza baza ma dwa dokumenty w tej kolekcji, więc ta liczba powinna się zgadzać.
Warto zweryfikować również pobieranie jednego elementu. Atrybut _id jest w naszej bazie nadawany automatycznie, więc raczej nie ma sensu sprawdzanie, czy da się znaleźć coś właśnie po nim. Możemy za to przetestować name.
it('should return a proper document by "name" with "findOne" method', async () => {
const department = await Department.findOne({ name: 'Department #1' });
const expectedName = 'Department #1';
expect(department.name).to.be.equal('Department #1');
});
Ponownie kod nie jest zbyt skomplikowany. Szukamy w kolekcji jednego z naszych dokumentów, a następnie sprawdzając atrybut name ustalamy, czy otrzymaliśmy prawidłowy.
Analogicznie jesteśmy w stanie testować kolejne atrybuty, choć akurat Department ma tylko jeden. Najczęściej kolekcje mają ich więcej, moglibyśmy zatem sprawdzić od razu wszystkie atrybuty w jednym teście.
Na końcu warto zrobić jeszcze jedną rzecz. Nie chcemy, aby zmiany poczynione w bazie danych, były widoczne również w kolejnych testach. Wtedy bowiem, nawet zwykła zmiana ich kolejności, mogłaby powodować wadliwe działanie. Bezpieczniej będzie przygotowywać bazę osobno dla każdej grupy testów, dlatego też po sprawdzeniu Read warto wyzerować dane w kolekcji Document.
Dodaj więc do bloku Reading data następujący hook after:
after(async () => {
await Department.deleteMany();
});
Czas sprawdzić, czy nasze testy działają poprawnie. Odpal task test lub test:watch i zobacz, jaki jest rezultat.
Create
Musimy przekonać się, czy możemy poprawnie dodawać nowe dane do kolekcji, zajmiemy się więc metodą save.
Ponownie będziemy weryfikować tylko to, czy w przypadku poprawnych danych, dokument zostanie zapisany do kolekcji. Nie musimy jeszcze raz sprawdzać sytuacji, w której podano błędne informacje.
Bierzmy się do pracy. Pod Reading data, utwórz nowy blok – Creating data, a wewnątrz it.
describe('Creating data', () => {
it('should insert new document with "insertOne" method', async () => {
});
});
Tym razem nie musimy wykorzystywać hooka before i przygotowywać w jakiś sposób bazy danych, bo fakt, że jest pusta, wcale nam nie przeszkadza.
Jak będzie wyglądał ten test? Podobnie do dwóch poprzednich przypadków – też jest intuicyjny. Postaramy się dodać nowy element, a potem po prostu sprawdzić, czy istnieje w naszej kolekcji.
it('should insert new document with "insertOne" method', async () => {
const department = new Department({ name: 'Department #1' });
await department.save();
const savedDepartment = await Department.findOne({ name: 'Department #1' });
expect(savedDepartment).to.not.be.null;
});
Czy wszystko jest tutaj dla Ciebie jasne? Dodajemy nowy element do kolekcji, następnie staramy się go odnaleźć, a na końcu sprawdzamy, czy się to udało.
Jeśli szukanie się nie powiedzie, findOne zwróci null. Polecenie to.not.be.null sprawdza, czy dostaliśmy coś innego niż null, ponieważ będzie to oznaczało, że otrzymaliśmy dokument.
Całość możemy kodować prościej, wykorzystując isNew. Jeśli dokument nie był jeszcze zapisany w bazie danych, to jego atrybut isNew jest równy true, a gdy został już do niej poprawnie wprowadzony, wartość tego atrybutu zwróci false. Z tą wiedzą możemy zmodyfikować nasz test następująco:
it('should insert new document with "insertOne" method', async () => {
const department = new Department({ name: 'Department #1' });
await department.save();
expect(department.isNew).to.be.false;
});
Następnie uruchom testy i sprawdź, czy udało się przejść wszystko pozytywnie. Jeśli podążasz zgodnie z instrukcjami, to efekt powinien być następujący:
Na końcu warto byłoby tylko zadbać o usunięcie tego testowego dokumentu. Możemy ponownie wykorzystać hook after:
after(async () => {
await Department.deleteMany();
});
Update
Pozostały nam do zweryfikowania jeszcze dwie operacje CRUD – Update i Delete. Zaczniemy od tej pierwszej. Warto tu przetestować metody updateOne i updateMany, ale również ideę modyfikacji danych z użyciem save.
Zacznij od utworzenia nowego bloku describe i trzech it.
describe('Updating data', () => {
it('should properly update one document with "updateOne" method', async () => {
});
it('should properly update one document with "save" method', async () => {
});
it('should properly update multiple documents with "updateMany" method', async () => {
});
});
Zanim przejdziemy do samego sprawdzania, musimy jeszcze przygotować testowe dane. Przyda nam się do tego hook beforeEach. Dlaczego ten, a nie before? Dlatego, że każdy test będzie modyfikował dane, idealnie byłoby więc je przywracać do stanu początkowego przed każdym kolejnym testem.
beforeEach(async () => {
const testDepOne = new Department({ name: 'Department #1' });
await testDepOne.save();
const testDepTwo = new Department({ name: 'Department #2' });
await testDepTwo.save();
});
Od razu możemy dodać też hook afterEach, który po każdym teście będzie usuwał dane z kolekcji, aby można było je wprowadzić od nowa.
afterEach(async () => {
await Department.deleteMany();
});
Czas zabrać się już za sam test:
it('should properly update one document with "updateOne" method', async () => {
await Department.updateOne({ name: 'Department #1' }, { $set: { name: '=Department #1=' }});
const updatedDepartment = await Department.findOne({ name: '=Department #1=' });
expect(updatedDepartment).to.not.be.null;
});
Wykorzystujemy tutaj ideę bardzo podobną do tej, którą pokazywaliśmy już w pierwszej wersji testu dodawania dokumentu. Staramy się zaktualizować jeden z dokumentów, następnie sprawdzamy, czy rzeczywiście istnieje on teraz w kolekcji. Znowu bazujemy na tym, że findOne zwraca dokument (jeśli znajdzie pasujący) albo null (jeśli takiego nie ma).
Przejdźmy do drugiego testu. Mongoose, mimo tego, że zezwala na użycie updateOne, zaleca korzystanie z metody save do aktualizacji pojedynczego dokumentu. Musimy więc przetestować również taki scenariusz.
it('should properly update one document with "save" method', async () => {
const department = await Department.findOne({ name: 'Department #1' });
department.name = '=Department #1=';
await department.save();
const updatedDepartment = await Department.findOne({ name: '=Department #1=' });
expect(updatedDepartment).to.not.be.null;
});
Test jest bardzo podobny do pierwszego. Z tą różnicą, że tym razem korzystamy w aktualizacji danych z metody save. Oczywiście nie zapominajmy, że tak naprawdę pod maską save też korzysta z metody updateOne.
Pozostał nam jeszcze trzeci test.
Tym razem postaramy się sprawdzić, czy metoda updateMany zadziała poprawnie. Jak będzie wyglądał ten test? Najpierw spróbujemy zmodyfikować dane za pomocą metody updateMany, a następnie pobierzemy zawartość kolekcji i sprawdzimy, czy faktycznie wartości zostały zaktualizowane.
Spróbuj wykonać ten test bez naszej pomocy.
Pokaż odpowiedź
Ukryj odpowiedź
it('should properly update multiple documents with "updateMany" method', async () => {
await Department.updateMany({}, { $set: { name: 'Updated!' }});
const departments = await Department.find();
expect(departments[0].name).to.be.equal('Updated!');
expect(departments[1].name).to.be.equal('Updated!');
});
Można zrobić to także sprytniej:
it('should properly update multiple documents with "updateMany" method', async () => {
await Department.updateMany({}, { $set: { name: 'Updated!' }});
const departments = await Department.find({ name: 'Updated!' });
expect(departments.length).to.be.equal(2);
});
Jeśli wszystkie testy zostały napisane poprawnie, modele powinny przejść je bez problemu, oczywiście o ile dobrze działają.
Delete
Czas na ostatnie testy. Musimy jeszcze sprawdzić metody deleteOne i deleteMany oraz remove (która tak naprawdę jest skrótem od deleteOne, ale i tak musi być zweryfikowana).
Zacznij od przygotowania bloku describe oraz trzech it.
describe('Removing data', () => {
it('should properly remove one document with "deleteOne" method', async () => {
});
it('should properly remove one document with "remove" method', async () => {
});
it('should properly remove multiple documents with "deleteMany" method', async () => {
});
});
Zanim przejdziemy do samych testów, zastanówmy się, czy nie potrzebujemy jakichś danych. Żeby coś usunąć, musimy najpierw to utworzyć, przygotujmy więc odpowiedni blok beforeEach, który zapewni nas, że zawsze będą istniały odpowiednie testowe dane. Dodajmy również blok afterEach, odpowiedzialny za każdorazowe zerowanie kolekcji po skończonych testach.
beforeEach(async () => {
const testDepOne = new Department({ name: 'Department #1' });
await testDepOne.save();
const testDepTwo = new Department({ name: 'Department #2' });
await testDepTwo.save();
});
afterEach(async () => {
await Department.deleteMany();
});
Następnie możemy zabrać się już za same testy. Zaczniemy od pierwszego. Spróbuj go wykonać bez naszej pomocy.
Pokaż odpowiedź
Ukryj odpowiedź
it('should properly remove one document with "deleteOne" method', async () => {
await Department.deleteOne({ name: 'Department #1' });
const removeDepartment = await Department.findOne({ name: 'Department #1' });
expect(removeDepartment).to.be.null;
});
Usuwamy dokument, następnie staramy się go znaleźć, a na końcu sprawdzamy, czy findOne zgodnie z planem zwróciło null. Taka wartość sugeruje bowiem, że elementu nie udało się zlokalizować, a więc już nie istnieje.
Co do drugiego testu, nie wykorzystywaliśmy remove zbyt często w praktyce, dlatego też tym razem Ci pomożemy.
Plan jest prosty. Musimy znaleźć jeden dokument, następnie postaramy się go usunąć, wywołując na nim bezpośrednio metodę remove. Na końcu sprawdzimy, czy element stanie się po tej operacji nullem.
it('should properly remove one document with "remove" method', async () => {
const department = await Department.findOne({ name: 'Department #1' });
await department.remove();
const removedDepartment = await Department.findOne({ name: 'Department #1' });
expect(removedDepartment).to.be.null;
});
Pozostał nam jeszcze ostatni test. Spróbuj go wykonać bez naszej asysty. Musimy sprawdzić, czy metoda deleteMany poprawnie usunie więcej niż jeden dokument.
Pokaż odpowiedź
Ukryj odpowiedź
it('should properly remove multiple documents with "deleteMany" method', async () => {
await Department.deleteMany();
const departments = await Department.find();
expect(departments.length).to.be.equal(0);
});
Usuwamy wszystkie dokumenty i sprawdzamy, czy kolekcja jest teraz pusta.
Jeśli wszystkie testy zostały napisane poprawnie, a baza danych działa bezbłędnie, to naszym oczom powinien ukazać się bardzo przyjemny widok:
Podsumowanie
Jak widzisz, nie było to aż takie trudne, a przynajmniej mamy pewność, że wszystkie operacje będą wykonywać się poprawnie.
Warto poruszyć jeszcze dwie kwestie, które mogą Cię zastanawiać. Po pierwsze, czy nie można połączyć testów schematów z testami metod? Jak najbardziej można. Wtedy byłyby to testy integracyjne i siłą rzeczy stałyby się trochę trudniejsze do napisania. Niemniej jednak, w swoich przyszłych aplikacjach, jak najbardziej możesz korzystać z takiego pomysłu.
Po drugie, czy możemy testować działanie metod od razu na prawdziwej bazie danych? Tak, jednak wtedy musimy pamiętać o tym, aby łączyć się z bazą testową, a nie produkcyjną. Krótko mówiąc, musimy mieć pewność, że jesteśmy w izolacji z bazą, z której korzystają użytkownicy. Inaczej musielibyśmy wziąć pod uwagę, że wszystkie dane są zmienne. W takiej sytuacji test próbujący dodać dokument o nazwie test mógłby przejść za pierwszym razem poprawnie, a za drugim już nie. Wystarczyłoby, że atrybut name jest unikalny, a akurat przed odpaleniem testu po raz drugi, ktoś by taki dokument dodał. Do tego, musielibyśmy poprawnie usuwać dane testowe oraz pilnować się, aby przypadkiem nie wpływać na pozostałe.
Przyjrzymy się tym zagadnieniom w kolejnym submodule.